跳到主要内容

长连接保活、断线重连、休眠唤醒后的状态恢复怎么设计?

考察点​

  • agent 长连接的完整健壮性设计
  • 心跳、重连、消息可靠性三板斧

参考答案​

1. 整体架构​

长连接放主进程(不放渲染进程):

  • 渲染进程随时可能关闭/崩溃,主进程常驻
  • 窗口全关后连接仍在,agent 才能收推送
  • 收到消息后由主进程路由给需要的窗口

2. 心跳保活​

class Connection {
constructor(url) {
this.url = url;
this.heartbeatInterval = 25_000; // 25s 心跳(小于运营商 NAT 超时)
this.heartbeatTimeout = 10_000; // 10s 未回 pong 判死
}

startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
// 应用层 ping(比 ws 协议层 ping 更可靠,能穿透中间代理)
this.send({ type: 'ping', ts: Date.now() });
// 启动 pong 超时检测
this.pongTimer = setTimeout(() => {
this.logger.warn('心跳超时,判定连接死亡');
this.ws.terminate(); // 强制断开,触发重连
}, this.heartbeatTimeout);
}, this.heartbeatInterval);
}

onPong() {
clearTimeout(this.pongTimer); // 收到 pong,取消超时
}
}

要点:

  • 应用层心跳优先于 WebSocket 协议层 ping/pong(协议层 ping 无 JS API 且部分代理不转发)
  • 心跳间隔经验值 25-60s:太长会被 NAT/防火墙静默断连,太短费电耗流
  • 必须有超时判死:只发不收检测,"僵尸连接"(半开状态)是 agent 假在线的头号原因

3. 断线重连​

reconnect() {
// 指数退避 + 抖动,防服务端被重连风暴打挂
const baseDelay = Math.min(1000 * 2 ** this.retryCount, 60_000);
const jitter = Math.random() * 1000;
const delay = baseDelay + jitter;

this.retryCount++;
setTimeout(() => this.connect(), delay);
}

onOpen() {
this.retryCount = 0; // 连上后重置退避
this.resync(); // 状态同步
}

触发重连的时机(多源监听):

  • ws.on('close') / ws.on('error')
  • 心跳超时主动 terminate
  • powerMonitor resume(系统唤醒,连接大概率已死,立即重连不等心跳)
  • 网络状态变化(online 事件 / 周期探测)

4. 休眠唤醒的处理(桌面端特有)​

powerMonitor.on('suspend', () => {
// 休眠前主动断开,标记离线,停止心跳
this.gracefulClose('system-suspend');
});

powerMonitor.on('resume', () => {
// 唤醒立即重连(此时网络可能还没就绪,退避重试兜底)
this.reconnect();
});

不处理 suspend 的恶果:休眠期间定时器冻结,唤醒后代码以为连接还活着(心跳 timer 一恢复发现超时很久才判死),中间几分钟消息全丢。

5. 消息可靠性(连接会断,消息不能丢)​

(1)离线消息队列(发送方向)

class MessageQueue {
constructor() {
this.pending = []; // 待确认消息
}

send(msg) {
msg.id = uuid();
this.pending.push(msg);
this.tryFlush();
}

tryFlush() {
if (!this.connected) return;
for (const msg of this.pending) {
this.ws.send(JSON.stringify(msg));
}
}

onAck(msgId) {
// 服务端 ack 后才移除,否则重连后重发
this.pending = this.pending.filter((m) => m.id !== msgId);
}

onReconnect() {
this.tryFlush(); // 重连后重发所有未 ack 消息
}
}

(2)接收方向:序号 + 拉取补偿

  • 服务端消息带递增 seq,客户端记录 lastSeq
  • 重连后上报 lastSeq,服务端补发缺口(类似 Kafka offset)

(3)去重:重发/补发会导致重复消息,消费端按 msg.id 幂等去重。

6. 状态机视角​

disconnected → connecting → connected → (heartbeat ok)
↑ ↓ ↓
└─── retry ── fail close/timeout ──→ reconnecting

明确的状态机 + 状态变更事件推给 UI(托盘图标变灰、窗口显示"连接中"),用户可感知连接健康度。

7. 常见坑​

  1. 只监听 close 不做心跳超时:半开连接永远不触发 close
  2. 重连无退避:服务端故障恢复瞬间被海量客户端同时重连打挂(雷霆万钧问题),抖动是关键
  3. 多实例多连接:单实例锁保证只有一个连接(服务端按设备踢重连,双连接会互踢)
  4. token 过期:重连时先刷新鉴权 token,401 类错误走刷新流程而不是无限重连

面试话术​

长连接四件套:应用层心跳 + 超时判死(治僵尸连接);指数退避加抖动重连(治重连风暴);suspend/resume 主动断开与立即重连(治休眠假在线);离线队列 + ack + seq 补偿 + 幂等去重(治消息丢失与重复)。连接放主进程常驻,状态机变化推给 UI 可感知。

常见追问​

  • 为什么不用 TCP 而选 WebSocket? WS 穿透性好(80/443 过防火墙代理)、协议成熟;TCP 更省但企业网络环境易被拦。
  • 服务端主动踢重连怎么设计? 同设备新连接建立时踢掉旧连接(防多端/僵尸),客户端被踢走正常重连流程。